Skip to content

turbopack: experimental minify-before-chunking feature - #98760

Open
andrewimm wants to merge 2 commits into
canaryfrom
andrewimm/minify-before-chunk
Open

andrewimm wants to merge 2 commits into
canaryfrom
andrewimm/minify-before-chunk

Conversation

@andrewimm

@andrewimm andrewimm commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Minification happens on a per-chunk basis. With enough chunks, we get good parallelization, but a large chunk can create a single large span that wastes unused cores.
This flag makes minification more granular, applying it to each module and caching the result. The goal is to make each minification step smaller, so that they can be evenly distributed across parallel workers. Theoretically this may also reduce the work done on a re-build, since the minification transform will only be applied to changed modules.

The flag is configured behind an experimental config flag, and only applies to builds (because we don't minify in dev!).

An ideal outcome is equal-or-faster builds, with almost no change to byte size. I don't expect much change to byte size, becuase the shape of our chunks doesn't lend itself to cross-module optimizations, but it's entirely possible we regress this a bit. That hypothesis will need to be tested against our benchmark suite of real Next apps.


Update: size measurements and follow-up changes

Changes in the second commit

  • Bug fix: per-item minification dropped the additional module ids of merged (scope hoisted) factories. The factory code is id2, id3, (ctx) => {…}; it was parsed as a sequence expression and SWC compressed the ids away. next build of bench/heavy-npm-deps failed with module factory is not available. The ids are now split off before minifying and re-emitted unchanged.
  • minify and minify_chunk_item share a single parse/optimize/print pipeline (review feedback).
  • The flag is now wired into NodeJsChunkingContext (the snapshot tests weren't exercising the per-item path before) and into the Next.js server and edge chunking contexts.
  • Chunk scaffolding is written already minified in all modes, and strict chunk items sort first. Strict layouts and thresholds match canary: flat below the thresholds, a strict IIFE for the strict factories of mixed chunks, and canary's (()=>{"use strict";…})() wrapper for all-strict chunks.
  • "use strict" handling: strict factories always keep their own directive, unminified and per-item minified alike, and Code records where it is (its own unmapped section). Chunk composition removes it only when the factory is inside a strict wrapper (CodeBuilder::push_code_without_strict_directive, using a new non-copying Rope::slice). If SWC drops a directive it proves unnecessary, nothing is recorded and nothing is removed. Execution tests (minification/strict-mode-layouts*) check strictness for flat, mixed and all-strict chunks in no-minify, whole-chunk and per-item modes.
  • Per-item mangling uses Deterministic (no char-frequency ordering), so names are consistent across factories and compress better.

Output size: per-item vs whole-chunk minification

Current (canary 2420b9f578, with swc-project/swc#12580 and the "use strict" rework)

Production next build of each app: A = canary, B = this branch flag off (whole-chunk), C = flag on (per-item). Sum of every .js under .next/static + .next/server, each file compressed separately (gzip -9, brotli -11). One run per variant.

per-item vs whole-chunk (C vs B) raw gzip brotli
bench/nested-deps-app-router-many-pages (100 pages, 5.26 MB) +1 B (+0.00%) +859 B (+0.05%) +618 B (+0.04%)
↳ client chunks +0 B +646 B (+0.27%) +976 B (+0.49%)
bench/heavy-npm-deps (1.40 MB) +0 B +842 B (+0.19%) +249 B (+0.07%)
  • Before the rework (same base): +4,157 B raw / +0.29% gzip / +0.57% brotli and +46 B / +0.20% / +0.01%. That raw overhead came from wrapping small mixed chunks, which per-item mode no longer forces: layout counts (flat / mixed / all-strict) and "use strict" counts are now identical in A, B and C.
  • Raw: the one byte is a module id the scaffolding writes as 606000 where whole-chunk minification writes 606e3.
  • Compressed: the rest comes from per-factory minification and mangling, plus client file names and hashes that differ between builds.
  • Flag off vs canary: emitted chunks are byte-for-byte identical (0 changed matched files); compressed totals differ by ≤0.04% from manifests.

Previous (before the SWC fix, base d8bfc3c994)

Flag on vs off, minification enabled in both. .js under .next/static + .next/server, gzip -9. next-swc was built from this branch in the debug profile (output bytes don't depend on the profile).

before (first commit) raw / gzip after raw / gzip
bench/heavy-npm-deps, all JS +0.41% / +0.89% +0.03% / +0.21%
bench/nested-deps-app-router-many-pages (100 pages), all JS +0.71% / +1.11% +0.19% / +0.48%
↳ client chunks +0.96% / +2.39% +0.01% / +0.26%
turbopack snapshot inputs (minify forced on) +1.19% / +1.27% +0.16% / +0.38%

Where the original overhead came from (e.g. a 183 KB client chunk with 106 factories, +1,612 B):

  • ~1,378 B: redundant "use strict" in every strict factory, since the chunk-level minifier normally strips these
  • ~250 B: unminified chunk scaffolding (newlines, ", ", globalThis["TURBOPACK"] || …)
  • the gzip-only part: each factory picking its own mangled-name order

At the time, part of the gap was a whole-chunk bug: SWC dropped the directive of the mixed-mode strict IIFE ([(()=>{"use strict";return[…]})(), …]), so strict factories ran sloppy and the baseline was smaller than correct output. swc-project/swc#12580 fixed this, and the numbers above are measured against the fixed baseline.

@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 1483657

@@ -0,0 +1,9 @@
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The minifyBeforeChunking snapshot tests run on the NodeJs runtime, but NodeJsChunkingContext never plumbs the flag, so it always returns the trait default false and the new per-item minify path is never exercised.

Fix on Vercel

Comment on lines +235 to +239
pub fn minify_chunk_item(
code: &Code,
source_maps: bool,
mangle: Option<MangleType>,
) -> Result<Code> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have a

pub fn minify(code: Code, source_maps: bool, mangle: Option<MangleType>) -> Result<Code> {

function in the same file. How is this one different?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original minify was written to handle chunks with side-effectful code, so it avoids eliminating any expressions.

Since a module is just an arrow function, it has no side effect and the minifier tries to delete the statement as a no-op. To get around this, I modify the AST to assign the arrow function to a variable called __TURBOPACK_CHUNK_ITEM__, minify it, and then delete the assignment. This leaves the minified arrow function.

But you're right that I'm probably duplicating code to achieve this. PRobably the right approach is:

  1. move most of minify to a core that handles a pre-parsed AST
  2. minify becomes a wrapper that parses the raw code and passes the AST to the implementation
  3. minify_chunk_item becomes a wrapper that generates the artificial AST, then passes it to the inner implementation method

That, or I add an optional pre- and post-processing step to the AST in minify

@lukesandberg
lukesandberg force-pushed the andrewimm/minify-before-chunk branch from fe1ba07 to a1ac34b Compare October 10, 2026 17:23
@github-actions github-actions Bot added created-by: Turbopack team PRs by the Turbopack team. Turbopack Related to Turbopack with Next.js. type: next labels Oct 10, 2026
@lukesandberg
lukesandberg added this pull request to stack #99984 October 10, 2026 18:05
@lukesandberg
lukesandberg force-pushed the andrewimm/minify-before-chunk branch from a1ac34b to 060bad6 Compare October 24, 2026 17:41
Minification happens on a per-chunk basis. With enough chunks, we get good
parallelization, but a large chunk can create a single large span that
wastes unused cores.
This flag makes minification more granular, applying it to each module and
caching the result. The goal is to make each minification step smaller, so
that they can be evenly distributed across parallel workers.
Theoretically this may also reduce the work done on a re-build, since the
minification transform will only be applied to changed modules.

The flag is configured behind an `experimental` config flag, and only
applies to builds (because we don't minify in dev!).

An ideal outcome is equal-or-faster builds, with almost no change to
byte size. I don't expect much change to byte size, becuase the shape
of our chunks doesn't lend itself to cross-module optimizations, but
it's entirely possible we regress this a bit. That hypothesis will
need to be tested against our benchmark suite of real Next apps.

Co-authored-by: Andrew Imm <andrew.imm@vercel.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
@lukesandberg
lukesandberg force-pushed the andrewimm/minify-before-chunk branch from 060bad6 to e0b7568 Compare October 24, 2026 20:59
}

#[test]
fn use_strict_directive_is_kept() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some of these tests are just testing the swc behavior, we should drop them

}

#[test]
fn use_strict_directive_can_be_stripped() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so this is testing an SWC behavior where directives can be stripped if they don't change semantics?

@@ -1,12 +1,10 @@
(()=>{"use strict";(globalThis["TURBOPACK"] || (globalThis["TURBOPACK"] = [])).push(["output/1jsg_tests_snapshot_basic-tree-shake_import-side-effect_input_1_hdh9its1vdz._.js",
"[project]/turbopack/crates/turbopack-tests/tests/snapshot/basic-tree-shake/import-side-effect/input/index.js [test] (ecmascript)", ((__turbopack_context__) => {
"use strict";(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["output/1jsg_tests_snapshot_basic-tree-shake_import-side-effect_input_1_hdh9its1vdz._.js","[project]/turbopack/crates/turbopack-tests/tests/snapshot/basic-tree-shake/import-side-effect/input/index.js [test] (ecmascript)",((__turbopack_context__) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im confused how use strict wound up here? does this need to be regenerated?

- Share one parse/optimize/print pipeline between `minify` and `minify_chunk_item`.
- Wire `minify_before_chunking` into the Node.js chunking context and the Next.js
  server and edge chunking contexts (previously only client chunks used it).
- Fix per-item minification dropping the additional module ids of merged
  (scope hoisted) factories (`id2, id3, <factory>` was parsed as a sequence and
  the ids compressed away).
- Move the per-item vs whole-chunk decision into `ChunkMinification`.
- Write chunk scaffolding in minified form in all modes, use a file-level
  `"use strict"` for all-strict chunks, and sort strict chunk items first.
- In per-item mode, strip each strict factory's own directive and always group
  strict factories under a strict wrapper, and use deterministic mangling so
  names are consistent across factories.

Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
@lukesandberg
lukesandberg force-pushed the andrewimm/minify-before-chunk branch from e0b7568 to 1483657 Compare October 27, 2026 08:42

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

created-by: Turbopack team PRs by the Turbopack team. Turbopack Related to Turbopack with Next.js. type: next

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants